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)
|
||||
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'
|
||||
login_manager.login_message = 'Veuillez vous connecter pour accéder à cette page.'
|
||||
|
|
|
|||
|
|
@ -77,7 +77,10 @@ WORK_TYPES = {
|
|||
|
||||
# Rôles utilisateurs
|
||||
ROLES = {
|
||||
"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",
|
||||
}
|
||||
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()])
|
||||
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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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"<User {self.username}>"
|
||||
|
|
|
|||
|
|
@ -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/<int:user_id>/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/<int:id>/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')
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
||||
|
|
|
|||
|
|
@ -1,161 +1,63 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Permissions - GMAO Collège{% endblock %}
|
||||
{% block title %}Rôles et permissions — GMAO{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1 class="h3"><i class="bi bi-shield-check"></i> Gestion des permissions</h1>
|
||||
<p class="text-muted">Gérer les accès par rôle et utilisateur</p>
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="h3"><i class="bi bi-shield-check"></i> Rôles et permissions</h1>
|
||||
<p class="text-muted mb-0">La matrice est centralisée dans l'application. Attribuez ici un rôle à chaque compte.</p>
|
||||
</div>
|
||||
<a href="{{ url_for('admin.users') }}" class="btn btn-outline-primary"><i class="bi bi-people"></i> Utilisateurs</a>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
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.
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
{% for role_key, role_name in roles.items() %}
|
||||
<div class="col-xl-4 col-md-6">
|
||||
<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 class="card-body">
|
||||
<h2 class="h6">Permissions</h2>
|
||||
{% if '*' in permissions_by_role[role_key] %}
|
||||
<span class="badge bg-danger">Accès complet</span>
|
||||
{% else %}
|
||||
<div class="d-flex flex-wrap gap-1 mb-3">
|
||||
{% for permission in permissions_by_role[role_key]|sort %}
|
||||
<span class="badge text-bg-light border">{{ permission }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if users_by_role[role_key] %}
|
||||
<hr>
|
||||
{% for user in users_by_role[role_key] %}
|
||||
<form method="post" action="{{ url_for('admin.update_user_permissions', id=user.id) }}" class="row g-2 align-items-center mb-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="col"><span class="small">{{ user.full_name or user.username }}</span></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>
|
||||
|
||||
<!-- Cartes de synthèse -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card">
|
||||
<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="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Permissions par rôle</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="{{ url_for('admin.update_permissions') }}" class="mb-4">
|
||||
<div class="row">
|
||||
{% for role, permissions in roles_permissions.items() %}
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="border rounded p-3">
|
||||
<h6 class="text-uppercase text-muted mb-3">{{ role|title }}</h6>
|
||||
<div class="row g-2">
|
||||
{% for perm in all_permissions %}
|
||||
<div class="col-6">
|
||||
<div class="form-check">
|
||||
<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>
|
||||
<select name="role" class="form-select form-select-sm" aria-label="Rôle de {{ user.username }}">
|
||||
{% for candidate, label in roles.items() %}
|
||||
<option value="{{ candidate }}" {% if candidate == role_key %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</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>
|
||||
<div class="col-auto"><button class="btn btn-sm btn-outline-primary" type="submit">Appliquer</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Légende -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Légende des permissions</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6>Dashboard</h6>
|
||||
<ul class="list-unstyled small">
|
||||
<li><code>dashboard:view</code> - Voir le tableau de bord</li>
|
||||
</ul>
|
||||
</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>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -18,7 +18,7 @@
|
|||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
{{ csrf_token() }}
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Nom d'utilisateur *</label>
|
||||
|
|
@ -41,9 +41,9 @@
|
|||
<div class="mb-3">
|
||||
<label for="role" class="form-label">Rôle *</label>
|
||||
<select class="form-select" id="role" name="role" required>
|
||||
<option value="user" {{ 'selected' if user and user.role == 'user' else '' }}>Utilisateur</option>
|
||||
<option value="tech" {{ 'selected' if user and user.role == 'tech' else '' }}>Technicien</option>
|
||||
<option value="admin" {{ 'selected' if user and user.role == 'admin' else '' }}>Administrateur</option>
|
||||
{% for role_key, role_name in roles.items() %}
|
||||
<option value="{{ role_key }}" {{ 'selected' if user and user.canonical_role == role_key else '' }}>{{ role_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -223,7 +223,7 @@
|
|||
</ul>
|
||||
</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-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">
|
||||
|
|
|
|||
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')
|
||||
password_hash = 'pbkdf2:sha256$fakehash$fakehash' # mot de passe factice
|
||||
full_name = factory.LazyAttribute(lambda obj: obj.username.replace('_', ' ').title())
|
||||
role = 'user'
|
||||
role = 'technicien'
|
||||
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