Centralise les rôles et les permissions
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run
This commit is contained in:
parent
b051930431
commit
d3ddfdb974
13 changed files with 367 additions and 200 deletions
|
|
@ -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.'
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
}
|
}
|
||||||
150
app_new/core/authorization.py
Normal file
150
app_new/core/authorization.py
Normal 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
|
||||||
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}>"
|
||||||
|
|
|
||||||
|
|
@ -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 d’utilisateur doit contenir au moins 3 caractères.', 'danger')
|
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')
|
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')
|
||||||
|
|
|
||||||
|
|
@ -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'))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
38
migrations/versions/a2b6c7d8e9f0_normalize_user_roles.py
Normal file
38
migrations/versions/a2b6c7d8e9f0_normalize_user_roles.py
Normal 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",
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
61
tests/integration/test_authorization.py
Normal file
61
tests/integration/test_authorization.py
Normal 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
|
||||||
Loading…
Reference in a new issue