Centralise les rôles et les permissions
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run

This commit is contained in:
root 2026-08-14 18:53:03 +00:00
parent b051930431
commit d3ddfdb974
13 changed files with 367 additions and 200 deletions

View file

@ -55,6 +55,10 @@ def create_app(config_name='default'):
migrate.init_app(app, db) migrate.init_app(app, db)
csrf.init_app(app) 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 # Configuration du login manager
login_manager.login_view = 'auth.login' login_manager.login_view = 'auth.login'
login_manager.login_message = 'Veuillez vous connecter pour accéder à cette page.' login_manager.login_message = 'Veuillez vous connecter pour accéder à cette page.'

View file

@ -77,7 +77,10 @@ WORK_TYPES = {
# Rôles utilisateurs # Rôles utilisateurs
ROLES = { ROLES = {
"tech": "Technicien",
"chef": "Chef de service",
"admin": "Administrateur", "admin": "Administrateur",
"responsable_gmao": "Responsable GMAO",
"technicien": "Technicien",
"assistant_prevention": "Assistant de prévention",
"demandeur": "Demandeur",
"lecture": "Lecture seule",
} }

View file

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

View file

@ -32,9 +32,12 @@ class UserForm(FlaskForm):
full_name = StringField("Nom complet", validators=[DataRequired()]) full_name = StringField("Nom complet", validators=[DataRequired()])
password = PasswordField("Mot de passe", validators=[Optional(), Length(min=12)]) password = PasswordField("Mot de passe", validators=[Optional(), Length(min=12)])
role = SelectField("Rôle", choices=[ role = SelectField("Rôle", choices=[
("tech", "Technicien"),
("chef", "Chef de service"),
("admin", "Administrateur"), ("admin", "Administrateur"),
("responsable_gmao", "Responsable GMAO"),
("technicien", "Technicien"),
("assistant_prevention", "Assistant de prévention"),
("demandeur", "Demandeur"),
("lecture", "Lecture seule"),
]) ])
is_active = BooleanField("Actif") is_active = BooleanField("Actif")

View file

@ -18,7 +18,7 @@ class User(UserMixin, db.Model):
email = db.Column(db.String(120), unique=True, nullable=True) email = db.Column(db.String(120), unique=True, nullable=True)
password_hash = db.Column(db.String(256), nullable=False) password_hash = db.Column(db.String(256), nullable=False)
full_name = db.Column(db.String(150), nullable=False, default="") 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) is_active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
last_login = db.Column(db.DateTime, nullable=True) last_login = db.Column(db.DateTime, nullable=True)
@ -33,7 +33,22 @@ class User(UserMixin, db.Model):
def is_admin(self): def is_admin(self):
"""Vérifie si l'utilisateur est admin.""" """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): def __repr__(self):
return f"<User {self.username}>" return f"<User {self.username}>"

View file

@ -8,7 +8,8 @@ from ...extensions import db
from ..models.user import User, Staff from ..models.user import User, Staff
from ..models.college import College, Building, Room, RoomType from ..models.college import College, Building, Room, RoomType
from ..models.company import Company, Service 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__) admin_bp = Blueprint('admin', __name__)
@ -62,7 +63,7 @@ def user_new():
email = (request.form.get('email') or '').strip() email = (request.form.get('email') or '').strip()
password = request.form.get('password') or '' password = request.form.get('password') or ''
password_confirm = request.form.get('password_confirm') 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: if len(username) < 3:
flash('Le nom dutilisateur doit contenir au moins 3 caractères.', 'danger') flash('Le nom dutilisateur doit contenir au moins 3 caractères.', 'danger')
@ -86,7 +87,7 @@ def user_new():
flash('Utilisateur créé avec succès.', 'success') flash('Utilisateur créé avec succès.', 'success')
return redirect(url_for('admin.users')) 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/<int:user_id>/edit', methods=['GET', 'POST']) @admin_bp.route('/users/<int:user_id>/edit', methods=['GET', 'POST'])
@ -97,8 +98,12 @@ def user_edit(user_id):
user = User.query.get_or_404(user_id) user = User.query.get_or_404(user_id)
if request.method == 'POST': 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.email = request.form.get('email')
user.role = request.form.get('role') user.role = role
user.is_active = request.form.get('is_active') == 'on' user.is_active = request.form.get('is_active') == 'on'
new_password = request.form.get('new_password') new_password = request.form.get('new_password')
@ -112,7 +117,7 @@ def user_edit(user_id):
flash('Utilisateur modifié avec succès.', 'success') flash('Utilisateur modifié avec succès.', 'success')
return redirect(url_for('admin.users')) 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') @admin_bp.route('/permissions')
@ -121,43 +126,29 @@ def user_edit(user_id):
def permissions(): def permissions():
"""Liste des permissions.""" """Liste des permissions."""
from ..models.user import User from ..models.user import User
from ...constants import ROLES users_by_role = {role: [] for role in ROLES}
for user in User.query.order_by(User.full_name).all():
# Compter les utilisateurs par rôle users_by_role[canonical_role(user.role)].append(user)
roles_permissions = {} return render_template(
for role_key, role_name in ROLES.items(): 'admin/permissions.html', roles=ROLES, role_labels=ROLE_LABELS,
roles_permissions[role_key] = User.query.filter_by(role=role_key).all() users_by_role=users_by_role, permissions_by_role=PERMISSIONS_BY_ROLE,
)
# 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'))
@admin_bp.route('/users/<int:id>/permissions', methods=['POST']) @admin_bp.route('/users/<int:id>/permissions', methods=['POST'])
@login_required @login_required
@admin_required @admin_required
def update_user_permissions(id): 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) user = User.query.get_or_404(id)
flash(f'Permissions de {user.username} mises à jour.', 'success') role = request.form.get('role')
return redirect(url_for('admin.user_permissions', id=id)) 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') @admin_bp.route('/users/list')

View file

@ -49,7 +49,7 @@ def logout():
@login_required @login_required
def list_users(): def list_users():
"""Liste des utilisateurs.""" """Liste des utilisateurs."""
if current_user.role != 'admin': if not current_user.is_admin():
flash("Accès réservé aux administrateurs.", "danger") flash("Accès réservé aux administrateurs.", "danger")
return redirect(url_for('dashboard.index')) return redirect(url_for('dashboard.index'))
@ -61,7 +61,7 @@ def list_users():
@login_required @login_required
def create_user(): def create_user():
"""Créer un utilisateur.""" """Créer un utilisateur."""
if current_user.role != 'admin': if not current_user.is_admin():
flash("Accès réservé aux administrateurs.", "danger") flash("Accès réservé aux administrateurs.", "danger")
return redirect(url_for('dashboard.index')) return redirect(url_for('dashboard.index'))
@ -98,7 +98,7 @@ def profile():
@login_required @login_required
def edit_user(id): def edit_user(id):
"""Modifier un utilisateur.""" """Modifier un utilisateur."""
if current_user.role != 'admin': if not current_user.is_admin():
flash("Accès réservé aux administrateurs.", "danger") flash("Accès réservé aux administrateurs.", "danger")
return redirect(url_for('dashboard.index')) return redirect(url_for('dashboard.index'))
@ -144,7 +144,7 @@ def change_own_password():
@login_required @login_required
def delete_user(id): def delete_user(id):
"""Supprimer un utilisateur.""" """Supprimer un utilisateur."""
if current_user.role != 'admin': if not current_user.is_admin():
flash("Accès réservé aux administrateurs.", "danger") flash("Accès réservé aux administrateurs.", "danger")
return redirect(url_for('dashboard.index')) return redirect(url_for('dashboard.index'))
@ -163,7 +163,7 @@ def delete_user(id):
@login_required @login_required
def change_user_password(id): def change_user_password(id):
"""Changer le mot de passe d'un utilisateur (admin only).""" """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") flash("Accès réservé aux administrateurs.", "danger")
return redirect(url_for('dashboard.index')) return redirect(url_for('dashboard.index'))

View file

@ -1,161 +1,63 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Permissions - GMAO Collège{% endblock %} {% block title %}Rôles et permissions — GMAO{% endblock %}
{% block content %} {% block content %}
<div class="container-fluid"> <div class="container-fluid">
<div class="row mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<div class="col"> <div>
<h1 class="h3"><i class="bi bi-shield-check"></i> Gestion des permissions</h1> <h1 class="h3"><i class="bi bi-shield-check"></i> Rôles et permissions</h1>
<p class="text-muted">Gérer les accès par rôle et utilisateur</p> <p class="text-muted mb-0">La matrice est centralisée dans l'application. Attribuez ici un rôle à chaque compte.</p>
</div>
<div class="col-auto">
<a href="{{ url_for('admin.list_users') }}" class="btn btn-outline-primary">
<i class="bi bi-people"></i> Gestion utilisateurs
</a>
</div>
</div> </div>
<a href="{{ url_for('admin.users') }}" class="btn btn-outline-primary"><i class="bi bi-people"></i> Utilisateurs</a>
</div>
<!-- Cartes de synthèse --> <div class="alert alert-info">
<div class="row mb-4"> Les permissions ne sont pas modifiables case par case : cela évite les droits invisibles et les écarts entre écrans.
<div class="col-md-3"> Toute évolution de la matrice est versionnée et testée.
<div class="card stat-card"> </div>
<div class="card-body text-center">
<i class="bi bi-person-shield fs-1 text-primary"></i>
<h5 class="mt-2">Administrateurs</h5>
<span class="badge bg-primary">{{ roles_permissions.admin|length }}</span>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card">
<div class="card-body text-center">
<i class="bi bi-tools fs-1 text-success"></i>
<h5 class="mt-2">Techniciens</h5>
<span class="badge bg-success">{{ roles_permissions.technicien|length }}</span>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card">
<div class="card-body text-center">
<i class="bi bi-building fs-1 text-warning"></i>
<h5 class="mt-2">Chefs</h5>
<span class="badge bg-warning">{{ roles_permissions.chef|length }}</span>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card">
<div class="card-body text-center">
<i class="bi bi-person-plus fs-1-info"></i>
<h5 class="mt-2">Demandeurs</h5>
<span class="badge bg-info">{{ roles_permissions.demandeur|length }}</span>
</div>
</div>
</div>
</div>
<!-- Tableau des permissions par rôle --> <div class="row g-3">
<div class="card"> {% for role_key, role_name in roles.items() %}
<div class="card-header"> <div class="col-xl-4 col-md-6">
<h5 class="mb-0">Permissions par rôle</h5> <div class="card h-100">
<div class="card-header d-flex justify-content-between">
<strong>{{ role_name }}</strong>
<span class="badge bg-secondary">{{ users_by_role[role_key]|length }} compte(s)</span>
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="post" action="{{ url_for('admin.update_permissions') }}" class="mb-4"> <h2 class="h6">Permissions</h2>
<div class="row"> {% if '*' in permissions_by_role[role_key] %}
{% for role, permissions in roles_permissions.items() %} <span class="badge bg-danger">Accès complet</span>
<div class="col-lg-6 mb-4"> {% else %}
<div class="border rounded p-3"> <div class="d-flex flex-wrap gap-1 mb-3">
<h6 class="text-uppercase text-muted mb-3">{{ role|title }}</h6> {% for permission in permissions_by_role[role_key]|sort %}
<div class="row g-2"> <span class="badge text-bg-light border">{{ permission }}</span>
{% for perm in all_permissions %} {% endfor %}
<div class="col-6"> </div>
<div class="form-check"> {% endif %}
<input class="form-check-input" type="checkbox"
name="permissions"
value="{{ perm }}"
id="perm_{{ role }}_{{ perm.replace('.', '_') }}"
{% if perm in permissions %}checked{% endif %}>
<label class="form-check-label small" for="perm_{{ role }}_{{ perm.replace('.', '_') }}">
{{ perm|replace(':', ': ')|replace('_', ' ')|capitalize }}
</label>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endfor %}
</div>
<div class="text-center">
<button type="submit" class="btn btn-primary">
<i class="bi bi-save"></i> Enregistrer les modifications
</button>
</div>
</form>
</div>
</div>
<!-- Légende --> {% if users_by_role[role_key] %}
<div class="card mt-4"> <hr>
<div class="card-header"> {% for user in users_by_role[role_key] %}
<h5 class="mb-0">Légende des permissions</h5> <form method="post" action="{{ url_for('admin.update_user_permissions', id=user.id) }}" class="row g-2 align-items-center mb-2">
</div> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="card-body"> <div class="col"><span class="small">{{ user.full_name or user.username }}</span></div>
<div class="row"> <div class="col-auto">
<div class="col-md-6"> <select name="role" class="form-select form-select-sm" aria-label="Rôle de {{ user.username }}">
<h6>Dashboard</h6> {% for candidate, label in roles.items() %}
<ul class="list-unstyled small"> <option value="{{ candidate }}" {% if candidate == role_key %}selected{% endif %}>{{ label }}</option>
<li><code>dashboard:view</code> - Voir le tableau de bord</li> {% endfor %}
</ul> </select>
</div>
<div class="col-md-6">
<h6>Interventions</h6>
<ul class="list-unstyled small">
<li><code>code:CREATE_INTERVENTION</code> - Créer</li>
<li><code>code:VIEW_INTERVENTIONS</code> - Voir</li>
<li><code>code:EDIT_INTERVENTIONS</code> - Modifier</li>
<li><code>code:DELETE_INTERVENTIONS</code> - Supprimer</li>
</ul>
</div>
<div class="col-md-6">
<h6>Équipements</h6>
<ul class="list-unstyled small">
<li><code>code:VIEW_EQUIPMENTS</code> - Voir</li>
<li><code>code:CREATE_EQUIPMENTS</code> - Créer</li>
<li><code>code:EDIT_EQUIPMENTS</code> - Modifier</li>
<li><code>code:DELETE_EQUIPMENTS</code> - Supprimer</li>
</ul>
</div>
<div class="col-md-6">
<h6>Planning</h6>
<ul class="list-unstyled small">
<li><code>code:VIEW_PLANNING</code> - Voir</li>
<li><code>code:CREATE_PLANNING</code> - Créer</li>
<li><code>code:EDIT_PLANNING</code> - Modifier</li>
</ul>
</div>
<div class="col-md-6">
<h6>ENT77</h6>
<ul class="list-unstyled small">
<li><code>code:VIEW_ENT</code> - Voir les messages</li>
<li><code>code:CONFIG_ENT</code> - Configurer</li>
<li><code>code:MANAGE_ENT_MESSAGES</code> - Gérer</li>
</ul>
</div>
<div class="col-md-6">
<h6>Administration</h6>
<ul class="list-unstyled small">
<li><code>code:VIEW_USERS</code> - Voir utilisateurs</li>
<li><code>code:CREATE_USERS</code> - Créer</li>
<li><code>code:EDIT_USERS</code> - Modifier</li>
<li><code>code:DELETE_USERS</code> - Supprimer</li>
<li><code>code:VIEW_ADMIN</code> - Voir admin</li>
</ul>
</div>
</div> </div>
<div class="col-auto"><button class="btn btn-sm btn-outline-primary" type="submit">Appliquer</button></div>
</form>
{% endfor %}
{% endif %}
</div> </div>
</div>
</div> </div>
{% endfor %}
</div>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -18,7 +18,7 @@
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<form method="POST"> <form method="POST">
{{ csrf_token() }} <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3"> <div class="mb-3">
<label for="username" class="form-label">Nom d'utilisateur *</label> <label for="username" class="form-label">Nom d'utilisateur *</label>
@ -41,9 +41,9 @@
<div class="mb-3"> <div class="mb-3">
<label for="role" class="form-label">Rôle *</label> <label for="role" class="form-label">Rôle *</label>
<select class="form-select" id="role" name="role" required> <select class="form-select" id="role" name="role" required>
<option value="user" {{ 'selected' if user and user.role == 'user' else '' }}>Utilisateur</option> {% for role_key, role_name in roles.items() %}
<option value="tech" {{ 'selected' if user and user.role == 'tech' else '' }}>Technicien</option> <option value="{{ role_key }}" {{ 'selected' if user and user.canonical_role == role_key else '' }}>{{ role_name }}</option>
<option value="admin" {{ 'selected' if user and user.role == 'admin' else '' }}>Administrateur</option> {% endfor %}
</select> </select>
</div> </div>

View file

@ -223,7 +223,7 @@
</ul> </ul>
</li> </li>
{% if current_user.role == 'admin' %} {% if current_user.is_admin() %}
<li class="nav-group-label d-lg-none mt-2">Administration</li> <li class="nav-group-label d-lg-none mt-2">Administration</li>
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {% if '/gmao-config' in request.path or '/outlook' in request.path or '/ent' in request.path or '/pronote' in request.path or '/yeastar' in request.path or '/status' in request.path or '/logs' in request.path or '/auth/users' in request.path or '/setup-wizard' in request.path %}active{% endif %}" href="#" role="button" data-bs-toggle="dropdown"> <a class="nav-link dropdown-toggle {% if '/gmao-config' in request.path or '/outlook' in request.path or '/ent' in request.path or '/pronote' in request.path or '/yeastar' in request.path or '/status' in request.path or '/logs' in request.path or '/auth/users' in request.path or '/setup-wizard' in request.path %}active{% endif %}" href="#" role="button" data-bs-toggle="dropdown">

View file

@ -0,0 +1,38 @@
"""Normalise les rôles utilisateurs.
Revision ID: a2b6c7d8e9f0
Revises: f1a5b6c7d8e9
Create Date: 2026-08-14
"""
from alembic import op
import sqlalchemy as sa
revision = "a2b6c7d8e9f0"
down_revision = "f1a5b6c7d8e9"
branch_labels = None
depends_on = None
def upgrade():
op.alter_column(
"users", "role", existing_type=sa.String(length=20),
type_=sa.String(length=32), nullable=False, server_default="demandeur",
)
op.execute("UPDATE users SET role = 'admin' WHERE role = 'super_admin'")
op.execute("UPDATE users SET role = 'responsable_gmao' WHERE role IN ('chef', 'responsable')")
op.execute("UPDATE users SET role = 'technicien' WHERE role IN ('tech', 'technician')")
op.execute("UPDATE users SET role = 'demandeur' WHERE role IN ('user', 'requester') OR role IS NULL OR role = ''")
op.execute("UPDATE users SET role = 'lecture' WHERE role = 'viewer'")
def downgrade():
op.execute("UPDATE users SET role = 'chef' WHERE role = 'responsable_gmao'")
op.execute("UPDATE users SET role = 'tech' WHERE role IN ('technicien', 'assistant_prevention')")
op.execute("UPDATE users SET role = 'user' WHERE role = 'demandeur'")
op.execute("UPDATE users SET role = 'viewer' WHERE role = 'lecture'")
op.alter_column(
"users", "role", existing_type=sa.String(length=32),
type_=sa.String(length=20), nullable=True, server_default="user",
)

View file

@ -13,7 +13,7 @@ class UserFactory(factory.alchemy.SQLAlchemyModelFactory):
email = factory.LazyAttribute(lambda obj: f'{obj.username}@test.local') email = factory.LazyAttribute(lambda obj: f'{obj.username}@test.local')
password_hash = 'pbkdf2:sha256$fakehash$fakehash' # mot de passe factice password_hash = 'pbkdf2:sha256$fakehash$fakehash' # mot de passe factice
full_name = factory.LazyAttribute(lambda obj: obj.username.replace('_', ' ').title()) full_name = factory.LazyAttribute(lambda obj: obj.username.replace('_', ' ').title())
role = 'user' role = 'technicien'
is_active = True is_active = True

View file

@ -0,0 +1,61 @@
"""Vérifie la matrice de rôles sur de vraies routes Flask."""
from uuid import uuid4
import pytest
from app_new import db
from app_new.core.authorization import canonical_role
from app_new.core.models.user import User
def _login_as(client, app, role):
client.get("/auth/logout")
password = "mot-de-passe-test-solide"
with app.app_context():
suffix = uuid4().hex[:8]
user = User(
username=f"rbac_{role}_{suffix}",
email=f"rbac_{role}_{suffix}@test.local",
full_name=f"Test {role}", role=role, is_active=True,
)
user.set_password(password)
db.session.add(user)
db.session.commit()
username = user.username
response = client.post("/auth/login", data={"username": username, "password": password})
assert response.status_code == 302
@pytest.mark.parametrize(
("legacy, canonical"),
[
("super_admin", "admin"), ("chef", "responsable_gmao"),
("tech", "technicien"), ("user", "demandeur"), ("viewer", "lecture"),
],
)
def test_legacy_roles_are_normalized(legacy, canonical):
assert canonical_role(legacy) == canonical
@pytest.mark.parametrize(
("role, equipment_status, admin_status"),
[
("admin", 200, 200),
("responsable_gmao", 200, 403),
("technicien", 200, 403),
("assistant_prevention", 200, 403),
("demandeur", 403, 403),
("lecture", 200, 403),
],
)
def test_role_access_to_real_pages(client, app, role, equipment_status, admin_status):
_login_as(client, app, role)
assert client.get("/equipments/").status_code == equipment_status
assert client.get("/admin/").status_code == admin_status
@pytest.mark.parametrize("role", ["demandeur", "lecture", "assistant_prevention"])
def test_non_maintainers_cannot_mutate_patrimony(client, app, role):
_login_as(client, app, role)
response = client.post("/equipments/categories/new", data={"name": "Interdit"})
assert response.status_code == 403